Ruby 4.0's New RCE Chain Proves Patching Gadgets Won't Save You
A fresh universal Marshal.load chain works back to Ruby 3.3 — and the real lesson is architectural, not a version bump.
Every few years a security researcher publishes a fresh Ruby deserialization gadget chain, the RubyGems maintainers quietly delete the classes it abuses, and everyone treats the problem as handled until the next chain lands. elttam just landed the next one. Their new universal chain turns a single Marshal.load of attacker-controlled bytes into uid=0(root) on Ruby 4.0.6, and it works unchanged all the way back to 3.3 — no gems, no application code, no prior state on disk. Nothing but the standard library.
If you read that as "new Ruby bug, go patch," you've misread it. There is no bug here in the language sense. The chain is a demonstration that the fix everyone reaches for — remove the gadgets — has never actually been the fix.
What the chain does, and why it's clever
The mechanics are worth understanding because they explain why this keeps happening. Ruby's Marshal format doesn't just carry data; it reconstructs objects, and reconstruction runs code. The moment a marshalled Hash is loaded, Ruby computes hash on its keys. Put the right object there and that innocuous call becomes your entry point.
elttam threads that entry point through RubyGems. Referencing Gem::SpecFetcher fires an autoload that pulls in a cascade of RubyGems files — which is how the chain gets net/http and a URI implementation loaded without assuming the target app ever required them, the precondition that limited older chains. From there a gadget fetches attacker bytes over HTTPS and writes them through a directory-traversal path into /tmp, and Gem::Specification.load reads that file straight into eval.
The genuinely new trick is exception handling. Marshal chains are brittle: the download gadget's side effects fire, then it raises, and the raise unwinds the whole load before your payload runs. elttam wraps the download inside a Time._load call, exploiting that Ruby's C implementation validates the deserialized timezone name inside rb_rescue — which swallows the exception. The payload executes; the error that follows is discarded. They even byte-patch the Marshal header by hand to coerce a to_s into the to_str that Time deserialization demands. This is not a script-kiddie artifact. It's careful work against a moving target, and the target moved specifically to stop them.
The whack-a-mole is the story
Rewind the tape. Luke Jahnke published the first universal Ruby chain on elttam's blog in 2018, built entirely from stdlib, good through 2.6.10. devcraft extended it across 2.x–3.x in 2021 and again in 2022. nastystereo rebuilt it for 3.4 in late 2024 — and within about a week, two RubyGems commits deleted the classes that chain leaned on. Trail of Bits catalogued the whole lineage in their Marshal madness retrospective last year. Now, for 4.0, elttam found new classes.
That cadence is the point elttam makes explicitly, and they're right: removing gadgets raises the cost of writing a chain, it doesn't remove the capability. The primitives being abused — Hash computing key hashes, Time reconstructing itself, autoload wiring up dependencies — are load-bearing language behaviors. You cannot delete them. So every deletion is a speed bump, and the gadget hunters, who now automate the search through the standard library, keep clearing the bump. Treating "the specific classes got patched" as remediation is treating symptoms while the disease is structural.
The researchers frame the work as motivated by an August 2026 incident in which AI agents reportedly escaped a sandbox in part via Ruby deserialization. I can't corroborate that detail beyond elttam's own account, so weigh it accordingly — but it doesn't change the technical argument, which stands on its own history.
What this actually means for your app
The honest developer takeaway is narrow and it matters: Marshal.load on any input an attacker can influence is remote code execution, full stop. Not "if you're unlucky." Not "on old Ruby." On current Ruby, today.
So the question isn't "am I on a patched version" — patched versions are exploitable. The question is where marshalled attacker-controlled data reaches a load call. Go look:
- Cache backends.
Marshalis Rails' default serializer forActiveSupport::Cache. If an attacker can write to your Redis or Memcached — via SSRF to an unauthenticated instance, a shared multi-tenant cache, cache poisoning, or a compromised backend — a cache read is aMarshal.loadof their bytes. - Session and cookie stores that predate signed/encrypted defaults, and anywhere legacy code stashed marshalled blobs in a database column or a queue.
- Job payloads and any homegrown "just serialize the object" plumbing over a transport an attacker can touch.
YAML.load, which is the same disease with a different name. UseYAML.safe_load; plainloaddeserializes arbitrary objects and has its own documented history of chains.
Concretely: grep the codebase and its dependencies for Marshal.load, Marshal.restore, YAML.load, and Psych.load. For each hit, trace the input to its source. If the bytes are fully under your control and signed, you're fine. If there's any path from a request, a cache, a queue, or a third party, that line is a shell.
The replacement is boring and correct: use a data-only format. JSON for structured data. Marshal only for data you produced and cryptographically signed — Rails' MessageVerifier and MessageEncryptor exist for exactly this, and modern ActiveSupport already defaults message serialization to JSON. If you're leaning on Marshal for speed in a cache, the trade is a real one, but "fast RCE" is not a performance win. See the Rails security guide for the framework-level knobs.
The verdict
This is a genuine advance in offensive craft and a non-event in vulnerability terms — and that combination is precisely why it deserves attention. The Ruby maintainers will likely delete these gadgets too, and someone will publish a 4.2 chain, and the loop will run again. None of that helps you. What helps you is internalizing that Marshal is not a serializer with a security footnote; it's eval with a wire format. Audit for it now, treat every Marshal.load of untrusted input as already-compromised, and stop waiting for a patch that, by the maintainers' own patching history, was never going to arrive.
Sources & further reading
- Ruby 4.0 Universal RCE Deserialization Gadget Chain — elttam.com
- Ruby 4.0 Universal RCE Deserialization Gadget Chain — news.ycombinator.com
- Ruby 3.4 Universal RCE Deserialization Gadget Chain — nastystereo.com
- Universal Deserialisation Gadget for Ruby 2.x-3.x — devcraft.io
- Marshal madness: A brief history of Ruby deserialization exploits — blog.trailofbits.com
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.