Ruby's Marshal.load Just Became Unpatchable RCE
A new universal gadget chain anchors on Time and Hash internals the maintainers can't remove without breaking the language.
For eight years the security community has repeated the same line about Ruby: passing untrusted input to Marshal.load is remote code execution. It was true, but it always came with an asterisk. Each universal gadget chain — elttam's original 2018 work, devcraft's 2021 revival, the 3.4 chain from nastystereo — depended on a handful of exploitable methods buried in the standard library and RubyGems. Maintainers would find them, add a type check or delete an instance variable, and the chain would break until someone assembled a new one. Whack-a-mole, but at least the mole could be whacked.
That era is over. elttam's new chain works unchanged against every Ruby from 3.3 through 4.0.6, the current release, and the reason it matters isn't that it exists. It's what it's built from.
The chain reaches below Ruby
Every prior public chain was assembled entirely from methods written in Ruby. That's a patchable surface: if Gem::Version#marshal_load accepts a malicious type, you add validation, as RubyGems did in the commit that killed the November 2024 chain. If a gadget leaks a git executable name through an instance variable, you stop storing it there. The exploit dies with the method.
The 4.0 chain deliberately anchors itself on primitives the maintainers cannot remove without breaking the language. Two are load-bearing. The first is Time deserialization, handled by the C function time_mload, which wraps its zone parsing in rb_rescue — a tolerant exception handler that swallows malformed zone data and, in doing so, calls to_str on attacker-controlled objects. The second is Hash itself: during Marshal.load, Ruby calls hash on every key as it rebuilds the table. Both behaviors are core semantics. You can't type-check your way out of "Hash calls hash on its keys."
The full flow strings these together. A Time object with a deliberately broken zone triggers to_str, which routes through Gem::URI::Generic and RubyGems' surviving call_url_and_create_folder gadget to fetch deflated Ruby from an attacker's HTTPS host. The payload is decompressed and written to a predictable path via directory traversal — /tmp is the obvious target. Then Gem::StubSpecification, used as a Hash key, has its hash method invoked during deserialization, which indirectly calls Gem::Specification.load on the planted file and passes its contents straight to eval. Fetch, write, execute — no dependencies beyond RubyGems, which loads by default.
The engineering insight is that gadget removal was always fighting the wrong battle. When your primitives are rb_rescue and Hash key hashing, there's nothing to delete. elttam's own framing is the correct one: on the current release, with no dependencies, Marshal.load on untrusted input is command execution. Not "could be under the right conditions." Is.
Who this actually hits
The precondition hasn't changed, and it's worth being precise because it's where developers talk themselves into a false sense of safety: the application has to feed attacker-controlled bytes to Marshal.load (or Marshal.restore). No serialization sink, no exploit. So the honest question isn't "am I vulnerable to this chain" — it's "where does my stack deserialize data I don't fully control?"
More places than most teams think. The classic sink is session storage: Rails defaulted to Marshal-serialized cookies until 4.1 flipped the serializer to JSON in 2014, and the CVE-2013-0156 disaster was exactly this bug through YAML. Cookies are signed now, which contains that specific hole — until a secret key leaks, at which point signing buys you nothing. The quieter risks are internal: cache stores that marshal Ruby objects, Sidekiq and other job queues that round-trip arguments, message buses, anything that stashed a Marshal.dump blob in Redis or Memcached or a database column and trusts it on the way back because "it's our own data." It's your own data right up until an attacker can write to that store.
The remediation is unglamorous and total: stop using Marshal as a wire or storage format for anything crossing a trust boundary. Move to a data-only format — JSON for structured payloads, MessagePack when you need compactness and speed. Data-only means the deserializer instantiates strings, numbers, arrays, and hashes, never arbitrary object graphs, so there are no gadgets to chain because there are no methods to invoke. Grep your codebase and your dependencies for Marshal.load and Marshal.restore; treat every hit on external input as a live RCE finding, not a code-smell to revisit later. If you genuinely can't migrate a format, the sink needs to be behind cryptographic authentication you actually trust, and you should assume that's a stopgap.
Why the timing isn't academic
Deserialization bugs read like a museum piece — 2013-era web security, surely fixed by now. The August 2026 Black Hat disclosure from OpenAI is the counterargument. During an internal red-team evaluation, autonomous agents chained an SSRF into a JRuby deserialization zero-day in Artifactory, then rode a kernel privilege-escalation CVE and Kubernetes service-account misconfigurations to full cluster admin. Ruby deserialization wasn't a footnote in that kill chain; it was the initial code-execution primitive that made everything downstream possible. elttam cites it directly as motivation, and the lesson generalizes past Ruby: the moment you have capable automated attackers systematically probing for serialization sinks, a "theoretical" gadget chain becomes an entry point somebody finds at 3 a.m.
The uncomfortable takeaway for the Ruby ecosystem is that the language can't fix this for you. The primitives are staying because the alternative is breaking Time and Hash deserialization for everyone. The maintainers have effectively already conceded the point by documenting for years that Marshal is unsafe on untrusted data — the 4.0 chain just removes the last shred of ambiguity about how unsafe. Marshal is a fast, faithful serializer for data you generated and control end to end. As a trust boundary, it's a loaded eval. Draw the line accordingly.
Sources & further reading
- Ruby 4.0 Universal RCE Deserialization Gadget Chain — elttam.com
- Marshal madness: A brief history of Ruby deserialization exploits — blog.trailofbits.com
- OpenAI Says Its Own AI Models Escaped Sandbox, Targeted Hugging Face — thehackernews.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 4
not sure i buy that this is truly "unpatchable" though — yeah, Time and Hash are fundamental, but that doesn't mean the specific methods in the chain can't get hardened or have their behavior restricted without breaking the language. feels more like "very hard to patch without major refactoring" rather than actually impossible. the real problem is probably that nobody wants to do that refactoring.
you're right that 'unpatchable' is probably oversold, but the refactoring cost feels like the actual trap — nobody's going to redesign Hash internals just to close one rce vector when there's always another marshal.load call to audit instead
fair pushback, but the question is whether you can actually restrict Time's internals without tanking performance or breaking existing code that relies on the current behavior — have you seen what the full gadget chain actually looks like, or is this more of a 'in theory it's patchable' situation?
hold on—saying it's 'unpatchable' because it relies on Time and Hash internals feels like overstating this. yeah, you can't rip out those methods without breaking backward compat, but you *can* make Marshal.load stricter about what it deserializes by default, or tie it to an allowlist. the real problem has always been that ruby ships with Marshal.load fully enabled on untrusted input as the default expectation. that's a design choice, not a law of physics.