Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 1,442 words · 3 segments analyzed
IntroductionOn August 5, 2026, OpenAI disclosed that a collective of AI agents under evaluation had broken out of their sandboxes and taken admin control of the cluster they were running on. It got there, in part, by exploiting Ruby deserialization to execute commands. That caught our attention, because in 2018 we published the first universal RCE deserialization gadget chain for Ruby, built entirely from the standard library with no dependencies. That chain works only against Ruby versions up to 2.6.10, and the most recent public chain only works up to 3.4-rc. This post releases a new universal chain that turns a single Marshal.load into command execution on Ruby 4.0.6, the most recent release at the time of writing, and works unchanged as far back as 3.3. The chain is built with new gadgets from untapped sources as well as old gadgets put to new use.BackgroundSerialization is the process of converting an object into a series of bytes which can then be transferred over a network or stored on the filesystem or in a database. These bytes include all the information required to reconstruct the original object. This reconstruction process is called deserialization. Each programming language typically has its own native serialization format and may refer to this process by a name other than serialization/deserialization. In the case of Ruby, the terms marshalling and unmarshalling are commonly used, and the operations are provided by Marshal.dump and Marshal.load.Thirteen years of Ruby deserializationUniversal Ruby deserialization gadget chains begin in 2018, built on earlier research into application specific chains against Ruby on Rails, and that universal work then fed back into the application specific chains that came after it. Several of the milestones below supply pieces that this chain builds on.January 10, 2013 - Rails 3.2.10 Remote Code Execution by Hailey SomervilleJanuary 31, 2013 - Ruby bug tracker issue by Hailey SomervilleMay 6, 2016 - Attacking Ruby on Rails Applications by joernchen of PhenoelitNovember 8, 2018 - Ruby 2.x Universal RCE Deserialization Gadget Chain by Luke Jahnke (elttam)January 2, 2019 - CVE-2019-5420 by ooooooo_qMarch 2, 2019 - Universal RCE with Ruby YAML.load by Etienne StalmansJune 20, 2019 - Remote Code Execution via Ruby on Rails Active Storage Insecure Deserialization by Sivathmican Sivakumaran and Pengsu Cheng (Trend Micro Security Research Team)January 7, 2021 - Universal Deserialisation Gadget for Ruby 2.x-3.x by William BowlingJanuary 9, 2021 - Universal RCE with Ruby YAML.load (versions > 2.7) by Etienne StalmansMarch 28, 2022 - Ruby Deserialization - Gadget on Rails by httpvoidApril 4, 2022 - Round Two: An Updated Universal Deserialisation Gadget for Ruby 2.x-3.x by William BowlingMay 17, 2022 - Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations by Ben Lincoln (Bishop Fox)March 13, 2024 - Discovering Deserialization Gadget Chains in Rubyland by Alex Leahu (Include Security)June 20, 2024 - Execute commands by sending JSON? Learn how unsafe deserialization vulnerabilities work in Ruby projects by Peter Stöckli (GitHub)October 17, 2024 - Updated ruby gadget for marshal loading by Leonardo Giovannini (Doyensec)November 24, 2024 - Ruby 3.4 Universal RCE Deserialization Gadget Chain by Luke JahnkeDecember 3, 2024 - Gem::SafeMarshal escape by Luke JahnkeAugust 20, 2025 - Marshal madness: A brief history of Ruby deserialization exploits by Matt Schwager (Trail of Bits)August 5, 2026 - Disclosure of in-the-wild exploitation of Ruby (JRuby) deserialization by autonomous AI agents, disclosed by OpenAI at Black Hat USA 20262026 - Ruby 4.0 Universal RCE Deserialization Gadget Chain by Luke Jahnke (this post)How the 3.4 chain brokeThe most recent public chain, published in late 2024, reached command execution on Ruby 3.4-rc with this payload: Marshal.dump( [ Gem::SpecFetcher, to_s_wrapper( call_url_and_create_folder( "rubygems.org/quick/Marshal.4.8/bundler-2.2.27.gemspec.rz" ) ), to_s_wrapper(exec_gadget) ] ) Ten days after it was published, two commits landed in RubyGems that removed the gadgets it relied on, each citing the writeup as motivation. Both shipped in Ruby 3.4.0, which is why the chain works against the release candidate but not against the release.The first commit, 62b49465f8, is titled "Improve type checking in marshal_load methods" and notes that it "Makes it harder to use those classes as gadgets".Gem::Version#marshal_load had passed the deserialized value straight to the constructor without validation, where Gem::Version.correct? calls to_s on it: def marshal_load(array) - initialize array[0] + string = array[0] + raise TypeError, "wrong version string" unless string.is_a?(String) + + initialize string end The second commit, 89ad04db86, is titled "Stop storing executable names in ivars" and notes that it "Removes usage of these classes as ACE gadgets".Gem::Source::Git and Gem::Resolver::GitSet had stored the git executable name in an instance variable, which Marshal restores directly and which was later handed to a process spawn: - @git = ENV["git"] || "git" The name is now read from the environment at the point of use, so there is no instance variable left to set.These two commits broke to_s_wrapper and exec_gadget, but Gem::SpecFetcher and call_url_and_create_folder were left alone and work in Ruby 4.0.Building a new chainExpanding the available set of gadgetsThe chain opens with Gem::SpecFetcher not because the class does any work, but because Marshal.load has to resolve the constant, and resolving it fires the RubyGems autoload that requires the file defining it, which in turn requires files of its own, and so on. A bare Ruby process therefore starts with a small set of classes reachable by a chain and ends up, after a single constant reference, with a much larger one to pick gadgets from, including Gem::URI::Generic, Gem::RequestSet::Lockfile and Gem::StubSpecification, all of which the rest of this chain depends on.Finding a new code execution destinationA suitable replacement for exec_gadget is supplied by Gem::Specification.load, where Gem.open_file resolves to File.open: class Gem::Specification < Gem::BasicSpecification def self.load(file) [...]
code = Gem.open_file(file, "r:UTF-8:-", &:read) begin spec = eval code, binding, file This method reads a file from disk and passes its contents directly to eval, so a chain that can control both the filename handed to Gem::Specification.load and the contents of that file ends up with arbitrary code execution.Calling the load methodThe available set offers no flexible gadget of the form @controlled.load(@also_controlled), but Gem::StubSpecification provides an indirect route to Gem::Specification.load(loaded_from) by calling the hash method. This works because loaded_from is an attr_accessor, so its value is held in @loaded_from and can be set through deserialization: def eval_file_gadget(filename) stub_specification = Gem::StubSpecification.allocate stub_specification.instance_variable_set(:@loaded_from, filename) return stub_specification end That leaves the question of how hash gets called during deserialization.Triggering the hash method callRuby invokes hash on an object whenever it is used as a key in a Hash. Marshal.load reconstructs a hash by inserting its keys, so placing the crafted Gem::StubSpecification as a key somewhere in the payload is enough to have hash called.Java aficionados will recognise this.HashMap.readObject calls hashCode on every key it restores, which is the entry point for a large share of the chains in ysoserial.The trigger is not a niche marshal_load override that a maintainer can quietly tighten, but the interaction between two fundamental features of the language, namely hashing an object and reconstructing a Hash during deserialization. Removing it would mean changing the way core data structures behave, which is exactly the kind of tradeoff where a gadget can be cheap to use and expensive to forbid.Getting code onto the filesystemBeing able to eval an arbitrary file on disk is only useful if the chain can also write attacker-controlled code to disk. Rather than build a new primitive for this, the chain reuses call_url_and_create_folder, which is one of the pieces of the 3.4-rc chain that the maintainers left untouched.In that earlier chain the gadget created the directories that the command-execution gadget depended on, since Gem::Source::Git began by changing into one of those directories and would fail if it did not already exist. Here it is put to a different use: its URL-download functionality fetches attacker-hosted content and writes that content onto the filesystem at a predictable and typically writable path by way of directory traversal.Triggering the downloadThe 3.4 chain invoked call_url_and_create_folder through to_s_wrapper, which the type checking commit removed, so the gadget needs a new caller.It also needs a caller that tolerates failure. The gadget expects the URL it fetches to hold a serialized object and raises when it does not, and what has to land on disk is Ruby source. A polyglot that is valid as both is not possible, because the Marshal header leaves no room for one. The download and the write happen before the parse, so the exception arrives after the useful work is done.Ruby's own Time deserialization provides both.
time_mload validates the zone name inside rb_rescue, which discards any exception it raises: static VALUE validate_zone_name(VALUE zone_name) { StringValueCStr(zone_name); return zone_name; } static VALUE time_mload(VALUE time, VALUE str) { [...] get_attr(zone, (zone = rb_rescue(validate_zone_name, zone, 0, Qnil))); [...] time_mload backs Time._load, which Marshal.load calls when rebuilding a Time.